<?PHP
	/************************************************************************
	*       This file, in addition to all other parts of X7 Chat are        *
	*                   Copyright 2003-2007 by Tim Chamness.                *
	* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	* Version:          X7 Chat 3.0.0-A2                                    *
	* Release Date:     October 20, 2007                                    *
	* Author:           Tim Chamness                                        *
	* Copyright:        2003-2007 by Tim Chamness                           *
	* Website:          http://www.x7chat.com/                              *
	* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
	* X7 Chat 3 is free software; you can redistribute it and/or modify     *
	* it under the terms of the GNU General Public License as published by  *
	* the Free Software Foundation; either version 3 of the License, or     *
	* (at your option) any later version.                                   *
	*                                                                       *
	* X7 Chat 3 is distributed in the hope that it will be useful, but      *
	* WITHOUT ANY WARRANTY; without even the implied warranty of            *
	* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU     *
	* General Public License for more details.                              *
	*                                                                       *
	* You should have received a copy of the GNU General Public License     *
	* along with this program.  If not, see <http://www.gnu.org/licenses/>. *
	*                                                                       *
	* Additional Terms, compatible with Section 7 of the GNU GPL v3,        *
	* apply to X7 Chat 3 and all X7 Chat 3 files, including this one.       *
	* You should have received a copy of these Additional Terms in the      *
	* file docs/COPYING.ADDITIONAL_TERMS.  If you did not receive a copy    *
	* please visit <http://www.x7chat.com/COPYING.ADDITIONAL_TERMS/>.       *
	************************************************************************/

	/**
	 * This pluggable library is designed to hold permanent data.  Data is stored
	 * in tables which contain multiple records with the same data fields.  Each table
	 * has a specific field which is the primary index.  For each record, this field
	 * must be a unique value.  Additionally, tables may have fields denoted as indexes
	 * which allow for faster record lookup when searching for a value in that field.
	 * Any indexed field must have a unique value in each record.
	 * 
	 * Each record must also store the last time it was modified.
	 */
	class x7database implements if_x7database {
		private $data_dir = "./data";		// The directory containing the table and record data
		
		private $table_data = array();		// A cache of the meta data for a table
		private $data_cache = array();		// A cache of the data in a record
		private $fname_cache = array();		// A cache of the filename for a given $where array
		
		/**
		 * Get a record iterator for a table.
		 *
		 * @throws dbexception If the table is invalid or corrupted
		 * @param string $table The table to get the record from
		 * @param mixed $where May be an integer if searching by primary index, otherwise
		 * 		must be an array of the form array('field name','field value').
		 * @param boolean $fuzzy If set to true the value in $where[1] will be treated as the
		 * 		regular expression "#^.*{$where[1]}.*$#".  Setting this to true will cause the db
		 * 		to not use any indexes.
		 * @return An iterator object which can be used to iterate through the results set
		 */
		public function get($table,$where=null,$fuzzy=false){
			
			try{
				// This function will get the filename if $where is looking by an indexed value
				// and $fuzzy == false
				$fname = $this->get_filename($table,$where);
			}catch(dbexception $dbex){
				
				switch($dbex->getCode()){
					case dbexception::MISSING_INDEX:
						// This $where condition requires a raw search with no index
						if(!$fuzzy){
							return new x7db_dir_iterator($table,$this,$where);
						}else{
							// A recoverable error occured and $fuzzy is true, search through all files
							// to try to find a partial match for the condition in $where
							return new x7db_dir_iterator($table,$this,$where,true);
						}
					break;
					case dbexception::CORRUPTED_TABLE:
					case dbexception::INVALID_TABLE:
					default:
						throw $dbex;
					break;
				}
				
			}
			
			// No errors occured, see if the file exists or not
			if($fname)
				return new x7db_single_record(null,null,$this->readrecord($fname));
			else
				return new x7db_single_record(null,null);
		}
		
		/**
		 * Do not call this directly.  Used to read data out of a file.
		 * Assumes that the passed file name is a valid, existing file.  Will not
		 * handle failure nicely so make sure you check before calling.
		 *
		 * @param string $fname The file to read
		 * @return The data contained in the specified file
		 */
		public function readrecord($fname){
			
			// Load data into cache if it does not exist
			if(!isset($this->data_cache[$fname])){
				require($fname);
				$this->data_cache[$fname] = $data;
			}
			
			return $this->data_cache[$fname];
		}
		
		/**
		 * Get a unique primary integer index ID for a table
		 *
		 * @param string $table The table to get the id for
		 * @return An integer id that is not in use as a primary key value for the table
		 */
		public function table_next_id($table){
			$id = time();
			while(is_file("{$this->data_dir}/{$table}/".sha1($id).'.php'))
				$id--;
			return $id;
		}
		
		/**
		 * Add or Overwrite a record in a table
		 *
		 * @throws dbexception If the table is invalid or corrupt, or required index values are missing,
		 * 		or duplicate required index values exist.
		 * @param string $table The table containing the record
		 * @param array $data The data to add to the table.  If the primary key value already
		 * 		exists then it it overwritten with the new data.  All indexed values must not
		 * 		already exist in the table (unless they exist in a record with the same primary id)
		 */
		public function put($table,$data){
			
			// Get data about the table so we know which field is the primary index
			$tdata = $this->get_tabledata($table);
				
			// Make sure a primary index value exists in the input values
			if(!isset($data[$tdata['primary']]))
				throw new dbexception(dbexception::MISSING_INDEX,$tdata['primary']);
			
			// Get a copy of the old data (so we know which indexes to update)
			// We can calculate the file name directly because the argument data is guaranteed to have
			$query = $this->get($table,$data[$tdata['primary']]);
			$old_data = $query->get_next();
				
			// Check indexes, this involves making sure that the data contains all required
			// index values and does not conflict with any existing values.
			$to_create = array();
			$to_remove = array();
			foreach($tdata['index'] as $index){
				
				// Make sure the input data contains a value for this index
				if(!isset($data[$index]))
					throw new dbexception(dbexception::MISSING_INDEX,$index);
				
				if(!isset($old_data[$index]) || $old_data[$index] != $data[$index]){
					
					// Make sure this index exists for the table
					if(!is_dir("{$this->data_dir}/$table/$index"))
						throw new dbexception(dbexception::CORRUPTED_TABLE,$index);
					
					// Make sure the data for this index is unique
					if(is_file("{$this->data_dir}/$table/$index/".sha1($data[$index]).'.php'))
						throw new dbexception(dbexception::DUPLICATE_INDEX,$index);
						
					if(isset($old_data[$index]))
						$to_remove[] = "{$this->data_dir}/$table/$index/".sha1($old_data[$index]).'.php';
					
					$to_create[] = "{$this->data_dir}/$table/$index/".sha1($data[$index]).'.php';
				}
			}
			
			// Update cache
			$fname = "{$this->data_dir}/$table/".sha1($data[$tdata['primary']]).'.php';
			$this->data_cache[$fname] = $data;
			
			// Write the data for this record
			file_put_contents($fname,'<?PHP $data = '.var_export($data,true).'; ?>');
			
			// Remove all the old index files that no longer apply to this record
			foreach($to_remove as $remove){
				unlink($remove);
			}
			
			// Create all the index files that need to be created
			foreach($to_create as $create){
				file_put_contents($create,$data[$tdata['primary']]);
			}
			
		}
		
		/**
		 * Delete a record from a table.
		 *
		 * @throws dbexception If the table is invalid or corrupt
		 * @param string $table The table to delete the record from
		 * @param mixed $where May be an integer if searching by primary index, otherwise
		 * 		must be an array of the form array('field name','field value').  Field name must
		 * 		be an indexed field.
		 * @return True on success, false on failure
		 */
		public function remove($table,$where){
			
			// Get table data so we know which indexes need updated
			$tdata = $this->get_tabledata($table);
			
			// If this returns false it means that the file does not exist
			// so it's already removed.
			if(!($fname = $this->get_filename($table,$where)))
				return true;
			
			// The file exists, so read the data about it so we can compute index values
			$old_data = $this->readrecord($fname);
			
			// Update cache
			unset($this->data_cache[$fname]);
			foreach(array_keys($this->fname_cache,$fname) as $fnamek){
				unset($this->fname_cache[$fnamek]);
			}
			
			// Remove the data file
			unlink($fname);
			
			// Remove the indexes
			foreach($tdata['index'] as $index_name){
				if(isset($old_data[$index_name]) && is_file("{$this->data_dir}/$table/$index_name/".sha1($old_data[$index_name]).'.php'))
					unlink("{$this->data_dir}/$table/$index_name/".sha1($old_data[$index_name]).'.php');
			}
			
			return true;
		}
		
		/**
		 * Get the number of records in a table.
		 *
		 * @throws dbexception If the table is invalid or corrupt
		 * @param string $table The name of the table to count the records in
		 * @return The number of records stored in that table or false on failure
		 */
		public function getsize($table){
			
			// Get the table data so we can subtract the number of index folders from the number
			// of items in the directory
			$tdata = $this->get_tabledata($table);
			
			// The -3 corrects for the existence of the files ., .., and index.php
			return (iterator_count(new DirectoryIterator("{$this->data_dir}/$table"))-count($tdata['index'])-3);
		}
		
		/**
		 * Get the last time a record was modified.
		 *
		 * @param string $table The name of the table containing the record
		 * @param mixed $where May be an integer if searching by primary index, otherwise
		 * 		must be an array of the form array('field name','field value').
		 * @return False on failure, otherwise the last time a file was modified.
		 */
		public function gettime($table,$where){
			
			if(!($fname = $this->get_filename($table,$where)))
				return false;
			
			return filemtime($fname);
		}
		
		/**
		 * Get the data storage directory
		 *
		 * @return the directory where data files are stored.
		 */
		public function get_data_dir(){
			return $this->data_dir;
		}
		
		/**
		 * Obtains the filesystem path and filename of a record and checks to see if it exists.
		 *
		 * @throws dbexception If the table is invalid or corrupted, or $where does refer to
		 * 		a valid index.
		 * @param string $table The table containing the record
		 * @param mixed $where May be an integer if searching by primary index, otherwise
		 * 		must be an array of the form array('field name','field value').
		 * @return A string containing the filename if the file exists, otherwise false.
		 */
		private function get_filename($table,$where){
			
			// If $where is null there is no point in continuing since a filename won't be found
			if($where === null)
				throw new dbexception(dbexception::MISSING_INDEX,'?');
			
			// See if this function has been called with these arguments before,
			// and if so look up the data from the cache.
			$cache_index = sha1($table.var_export($where,true));
			if(isset($this->fname_cache[$cache_index]))
				return $this->fname_cache[$cache_index];
			
			// See if a search is necessary
			if(is_array($where)){
				
				// Get table data about this table, this might throw an exception
				$tdata = $this->get_tabledata($table);
					
				// Check if this is the primary index
				if($tdata['primary'] == $where[0]){
					$where = $where[1];
				
				// Check if this is an indexed field
				}elseif(is_dir("{$this->data_dir}/$table/{$where[0]}/")){
					
					// See if this record exists
					if(!is_file("{$this->data_dir}/$table/{$where[0]}/".sha1($where[1]).".php"))
						return false;
					
					// Use the index to get the primary key value
					$fdata = file("{$this->data_dir}/$table/{$where[0]}/".sha1($where[1]).".php");
					$where = (int)$fdata[0];
					if(empty($where))
						throw new dbexception(dbexception::CORRUPTED_TABLE,$table);
					
				}else{
					// This field is not indexed, which means there could be more than one
					// record matching and a directory iterator must be used.
					throw new dbexception(dbexception::MISSING_INDEX,'?');
				}
				
			}else{
				
				// See if this table exists or not
				if(!is_dir("{$this->data_dir}/$table"))
					throw new dbexception(dbexception::INVALID_TABLE,$table);
				
			}
			
			$where = sha1($where);
			
			// Output filename if it is valid, otherwise throw an error
			if(is_file("{$this->data_dir}/$table/$where.php")){
				$this->fname_cache[$cache_index] = "{$this->data_dir}/$table/$where.php";
				return $this->fname_cache[$cache_index];
			}
			
			return false;
		}
		
		/**
		 * Get information about a table, such as it's primary index and secondary indexes
		 *
		 * @throws dbexception If the table is invalid or corrupted
		 * @param string $table The table to get information about
		 * @return An error code if an error occurs, otherwise an array containing the data
		 */
		private function get_tabledata($table){
			
			if(!isset($this->table_data[$table])){
			
				// Make sure this table exists
				if(!is_dir("{$this->data_dir}/$table"))
					throw new dbexception(dbexception::INVALID_TABLE,$table);
				
				// Make sure this table has data
				if(!is_file("{$this->data_dir}/$table/index.php"))
					throw new dbexception(dbexception::CORRUPTED_TABLE,$table);
				
				require("{$this->data_dir}/$table/index.php");
				if(!isset($data))
					throw new dbexception(dbexception::CORRUPTED_TABLE,$table);
				
				$this->table_data[$table] = $data;
			}
			
			return $this->table_data[$table];
		}
		
	}
	
	/**
	 * Used for iteration through a set of the records in a database table when
	 * the set of records cannot be pre-determined using an index value.
	 * 
	 * Objects of this class should not be created directly, they are returned
	 * by x7database::get() when appropriate.
	 */
	class x7db_dir_iterator implements if_x7iterator {
		private $table;
		private $dir;
		private $where;
		private $fuzzy;
		private $database;

		/**
		 * Create a new database iterator
		 *
		 * @param string $table The table to iterate through
		 * @param x7database $database The database controller object constructing this iterator
		 * @param array $where If specified this must be an array of the form
		 * 		array('field name','field value');  If specified, only records
		 * 		meeting the condition specified in $where will be returned.  Use only for
		 * 		non-indexed fields.
		 * @param boolean $fuzzy Set to true if the condition specified in $where should be
		 * 		searched for within the field's contents.
		 */
		public function __construct($table,$database,$where=null,$fuzzy=false){
			$this->table = $table;
			$this->dir = (is_dir($database->get_data_dir()."/{$table}/") ? dir($database->get_data_dir()."/{$table}/") : null);
			$this->database =& $database;
			$this->where = $where;
			$this->fuzzy = $fuzzy;
		}

		/**
		 * Get the data from the next record.
		 *
		 * @return The array of data contained in the next element or an empty array if all
		 * 		values have been iterated through.
		 */
		public function get_next(){
			if($this->dir === null) return array();
			$nxtfile = $this->dir->read();
			
			if(!$nxtfile)
				return array();
			elseif(!is_file($this->database->get_data_dir()."/{$this->table}/{$nxtfile}") || $nxtfile == 'index.php')
				return $this->get_next();
			else{
				$data = $this->database->readrecord($this->database->get_data_dir()."/{$this->table}/$nxtfile");
				
				// See if there is a where condition
				if(!empty($this->where)){
					if(!is_array($data))
						return $this->get_next();
					elseif(!isset($data[$this->where[0]]) || (!$this->fuzzy && $data[$this->where[0]] != $this->where[1]) || ($this->fuzzy && strpos($data[$this->where[0]],$this->where[1]) === false))
						return $this->get_next();
				}
				
				return $data;
			}
		}
		
	}
	
	/**
	 * This is used when only a single record matches the query.  It's somewhat
	 * pointless but helps increase compatibility.
	 * 
	 * Objects of this class should not be created directly, they are returned
	 * by x7database::get() when appropriate.
	 */
	class x7db_single_record implements if_x7iterator {
		private $record;

		/**
		 * Create a new database iterator
		 *
		 * @param string $table The table to iterate through
		 * @param x7database $database The database that constructed this iterator
		 * @param array $record The data to be returned when get_next() is called
		 */
		public function __construct($table,$database,$record=null){
			$this->record = $record ? $record : array();
		}

		/**
		 * Get the data from the next record.
		 *
		 * @return The array of data contained in the next element or an empty array
		 * 		if all items have been iterated through.
		 */
		public function get_next(){
			$record = $this->record;
			$this->record = array();
			return $record;
		}
		
	}
	
	class dbexception extends Exception {
		const INVALID_TABLE = 1;
		const CORRUPTED_TABLE = 2;
		const DUPLICATE_INDEX = 3;
		const MISSING_INDEX = 4;
		private $moreinfo;
		
		public function __construct($code,$moreinfo=''){
			$this->moreinfo = $moreinfo;
			
			switch($code){
				case self::INVALID_TABLE:
					$msg = "The table '$this->moreinfo' does not exist.";
				break;
				case self::CORRUPTED_TABLE:
					$msg = "The table '$this->moreinfo' is corrupted.";
				break;
				case self::DUPLICATE_INDEX:
					$msg = "Argument data contains an index value for field '$this->moreinfo' that conflicts with an existing record.";
				break;
				case self::MISSING_INDEX:
					$msg = "Argument data does not contain a required index value for field '$this->moreinfo'.";
				break;
				default:
					$msg = "A database query error.";
				break;
			}
			
			parent::__construct($msg,$code);
		}
		
		public function get_moreinfo(){
			return $this->moreinfo;
		}
	}
?>